Skip to content

[WIP] Implement RFC 41: lib.fixed#1578

Draft
vk2seb wants to merge 7 commits intoamaranth-lang:mainfrom
vk2seb:seb/fixed
Draft

[WIP] Implement RFC 41: lib.fixed#1578
vk2seb wants to merge 7 commits intoamaranth-lang:mainfrom
vk2seb:seb/fixed

Conversation

@vk2seb
Copy link
Contributor

@vk2seb vk2seb commented Apr 21, 2025

Overview

  • This is a staging ground for experimenting with the ergonomics of lib.fixed while the associated RFC is being worked on. It started as a fork of @zyp's early implementation of the RFC here, however a few things have changed since then.
    • Most importantly, this PR adds some tests, which makes it more obvious what the consequences of different design decisions will be.
    • Also, all operators required for real-world use are now implemented
  • As of now, this PR adheres to the latest version of the RFC, with some minor changes (these will be discussed further in the RFC issue):
    • New methods on fixed.Value - .saturate() and .clamp() - these are commonly needed in my DSP codebase, but it may make sense to punt these new methods to a future RFC.
    • .truncate() is added as an alias for .reshape(), the only difference being that it verifies there was a reduction of f_bits requested.
    • The numerator() method is dropped, as I found a way to combine it with as_value() reliably.
  • I have already integrated this implementation of lib.fixed in this Tiliqua PR and tested it underneath my library of DSP cores, and will continue to use the learnings there in order to guide the RFC.
  • It should be obvious that this PR needs a cleanup pass, improved diagnostics, and a lot of documentation work after the RFC is complete. However, it is already usable in real projects as all essential operations are implemented.

Simple example

Consider the implementation of a simple Low-pass filter, where we wish to compute the difference equation y = y[n-1] * beta + x * (1 - beta) using fixed point representation:

class OnePole(wiring.Component):

    def __init__(self, beta=0.9, sq=fixed.SQ(1, 15)):
        self.beta = beta
        self.sq = sq
        super().__init__({
            "x": In(sq),
            "y": Out(sq),
        })

    def elaborate():
        m = Module()
        a = fixed.Const(self.beta, shape=self.sq)
        b = fixed.Const(1-self.beta, shape=self.sq)
        # Quantization from wider to smaller fixed.Value occurs on the `.eq()`
        m.d.sync += self.y.eq(self.y*a + self.x*b)
        return m

@goekce
Copy link

goekce commented May 2, 2025

I noticed == seems to work differently than standard types. Is this intended? I noticed this while writing assertions:

from amaranth import *
from amaranth.lib import fixed
from amaranth.lib.wiring import Component, In, Out


class C(Component):
    o: Out(1)

    def elaborate(self, platform):
        m = Module()
        self.s = Signal(2)
        self.f = Signal(fixed.SQ(2, 2))
        m.d.sync += self.o.eq(self.s)
        return m


from amaranth.sim import Period, Simulator

dut = C()


async def bench(ctx):
    for _ in range(1):
        print(ctx.get(dut.s) == 0)  # True
        print(ctx.get(dut.f) == 0)  # (== (s (slice (const 4'sd0) 0:4)) (cat (const 2'd0) (const 1'd0)))
        await ctx.tick()


sim = Simulator(dut)
sim.add_clock(Period())
sim.add_testbench(bench)
sim.run()

outputs:

True
(== (s (slice (const 4'sd0) 0:4)) (cat (const 2'd0) (const 1'd0)))

@vk2seb
Copy link
Contributor Author

vk2seb commented May 2, 2025

@goekce Thanks for taking a look! I think this is happening because of the comparison outside the simulation context, rather than inside it, which means that from_bits is converting the type back up to a fixed.SQ. I see similar behaviour with other types from amaranth.lib, for example enum:

from amaranth import *
from amaranth.lib import fixed, enum
from amaranth.lib.wiring import Component, In, Out

class Funct4(enum.Enum, shape=unsigned(4)):
    ADD = 0
    SUB = 1
    MUL = 2

class C(Component):
    o: Out(1)

    def elaborate(self, platform):
        m = Module()
        self.s = Signal(2)
        self.e = Signal(Funct4)
        self.f = Signal(fixed.SQ(2, 2))
        m.d.sync += self.o.eq(self.s)
        return m


from amaranth.sim import Period, Simulator

dut = C()


async def bench(ctx):
    for _ in range(1):
        # raw result of from_bits()
        print(ctx.get(dut.s)) # 0
        print(ctx.get(dut.e)) # Funct4.ADD
        print(ctx.get(dut.f)) # fixed.SQ(2, 2) (const 4'sd0)
        # compare inside the simulation context
        print(ctx.get(dut.s == 0))                                    # 1
        print(ctx.get(dut.e == Funct4.ADD))                           # 1
        print(ctx.get(dut.f == 0))                                    # 1
        print(ctx.get(dut.f == fixed.Const(0, shape=fixed.SQ(2, 2)))) # 1
        # compare outside the simulation context
        print(ctx.get(dut.e) == 0) # False (even though Funct4.ADD is 0)
        print(ctx.get(dut.f) == 0) # (== (s (slice (const 4'sd0) 0:4)) (cat (const 2'd0) (const 1'd0)))
        await ctx.tick()


sim = Simulator(dut)
sim.add_clock(Period())
sim.add_testbench(bench)
sim.run()

As an example for writing assertions, maybe take a look at the tests attached to this PR. That being said, this is still a work in progress and not quite ready for review yet!

@vk2seb
Copy link
Contributor Author

vk2seb commented May 2, 2025

@goekce a related topic to your example's evaluation outside the simulation context is elaboration-time evaluation of constant expressions. which is something I'd like to leave out of this RFC, even if we could add it in the future. For example

>>> from amaranth import *
>>> Const(5) + Const(3)
(+ (const 3'd5) (const 2'd3))
>>> Const(5) == Const(3)
(== (const 3'd5) (const 2'd3))

@goekce
Copy link

goekce commented May 3, 2025

I was not aware of the possibility that I can do a simulation-time comparison. Thanks Seb, this would solve my problem.

As I understand, elaboration-time evaluation of constant expressions is extra work.👍

@whitequark
Copy link
Member

You can already do elaboration-time comparison of const expressions by doing Const.cast(a).value == Const.cast(b).value.

@vk2seb
Copy link
Contributor Author

vk2seb commented May 3, 2025

For comparison with zero that makes sense but for other values, if we drop the fixed.Shape this kind of comparison is not so intuitive

>>> Const.cast(fixed.Const(0.5, shape=fixed.SQ(2, 2))).value == 2
True

I think this kind of use case is better covered by as_float

fixed.Const(0.5, shape=fixed.SQ(2, 2)).as_float() == 0.5

In the example from @goekce - if we really want to do the comparison outside the sim context for some reason

print(ctx.get(dut.f).as_float() == 0.0) # True

@whitequark
Copy link
Member

What I mean is that Const.cast() is the Amaranth entry point for e.g. resolving concantenations or indexing into an Amaranth constant value. It is up to the end user or library how this API is used, I only wanted to mention that it is available.

@goekce
Copy link

goekce commented May 3, 2025

I am confused about how Const.cast() would be valuable here.

I thought Const.cast() is actually used in the following expression so that the signal provides directly an output that can be used in elaboration-time:

(Pdb) ctx.get(dut.s)
0

But it is probably not used:

(Pdb) Const.cast(dut.s)
*** TypeError: Value (sig s) cannot be converted to an Amaranth constant

Or do you just want to say that Const.cast() could be used for fixed values whenever an expression like with m.Case(...): as documented in the RFC about Const.cast()?

@goekce
Copy link

goekce commented May 4, 2025

If a Mux is an operand, the arithmetic result is wrong. I did not have the time to investigate further, but have the feeling that Mux cannot return that the result should be interpreted as a fixed shape:

self.a should output 0.25, but it does not:

from amaranth import *
from amaranth.lib import fixed
from amaranth.lib.wiring import Component, In, Out


class C(Component):
    y: Out(fixed.SQ(8, 4))

    def elaborate(self, platform):
        m = Module()
        self.a = Signal(self.y.shape())
        self.b = Signal(self.y.shape())
        self.c = Signal(self.y.shape())
        m.d.comb += [
            self.a.eq(fixed.Const(-1.125) + Mux(1, fixed.Const(1.375), 0)),
            self.b.eq(fixed.Const(-1.125) + fixed.Const(1.375)),
            self.c.eq(Mux(1, fixed.Const(1.375), 0)),
        ]
        m.d.sync += self.y.eq(self.a)
        return m


from amaranth.sim import Period, Simulator

dut = C()


async def bench(ctx):
    await ctx.tick()
    print(ctx.get(dut.a).as_float())
    print(ctx.get(dut.b).as_float())
    print(ctx.get(dut.c).as_float())
    print(Mux(1, fixed.Const(1.375), 0).shape())


sim = Simulator(dut)
sim.add_clock(Period())
sim.add_testbench(bench)
sim.run()

Result:

9.875
0.25
0.6875
unsigned(4)

Co-authored-by: Gökçe Aydos <18174744+goekce@users.noreply.github.com>
@vk2seb
Copy link
Contributor Author

vk2seb commented May 4, 2025

If a Mux is an operand, the arithmetic result is wrong. I did not have the time to investigate further, but have the feeling that Mux cannot return that the result should be interpreted as a fixed shape:

Thanks, I haven't played with Mux much in combination with fixed.Value, this makes a good candidate for some more test cases. There are some tests on the fixed.Value(shape, Mux(a, b, c)) statement inside fixed.Value.clamp(...), but in this case the 2 shapes are guaranteed to be the same, so I guess Mux losing the underlying type did not have an adverse effect and so I didn't catch it. In your example, forcing the incoming type to match also 'fixes' it: self.c.eq(Mux(1, fixed.Const(1.375, self.y.shape()), 0))

Ideally we want to attack this without touching anything outside lib.fixed, on a quick skim I think the information is lost on the cast to Value here. This also looks related to Shape._unify - we kind of need a similar _unify operation to automatically perform a .reshape() up to max(f_bits) in the same fashion as fixed.Value._binary_op already does.

It seems other types similarly lose the type information through Mux:

from amaranth.lib import enum, data

class Funct(enum.Enum, shape=4):
    ADD = 0
    SUB = 1
    MUL = 2

rgb565_layout = data.StructLayout({
    "red":   5,
    "green": 6,
    "blue":  5
})

print(Mux(1, Funct.ADD, Funct.MUL).shape()) # unsigned(4)
print(Mux(1, Signal(rgb565_layout), Signal(rgb565_layout)).shape()) # unsigned(16)

In this case it seems making Mux more intelligent to what is happening to fixed.Value would be different to how existing types behave. What do you think @whitequark ? I would like to avoid touching the infrastructure underneath Mux if we can here, and then we could address preserving shapes through Mux in a future RFC?

@whitequark
Copy link
Member

I would like to avoid touching the infrastructure underneath Mux if we can here, and then we could address preserving shapes through Mux in a future RFC?

Yeah, that sounds reasonable. We actually discussed the option of preserving shapes through Mux and SwitchValue; @wanda-phi, do you recall what came out of those discussions?

@goekce
Copy link

goekce commented May 5, 2025

In your example, forcing the incoming type to match also 'fixes' it: self.c.eq(Mux(1, fixed.Const(1.375, self.y.shape()), 0))

If Mux is an operand, then forcing does not fix 😕

...
            self.c.eq(Mux(1, fixed.Const(1.375), 0)),
            self.d.eq(Mux(1, fixed.Const(1.375, self.y.shape()), 0)),
            self.e.eq(fixed.Const(-1.125) + Mux(1, fixed.Const(1.375, self.y.shape()), 0)),
            self.f.eq(fixed.Const(-1.125, self.y.shape()) + Mux(1, fixed.Const(1.375, self.y.shape()), 0)),
            self.g.eq(fixed.Const(-1.125, self.y.shape()) + Mux(1, fixed.Const(1.375, self.y.shape()), fixed.Const(0, self.y.shape()))),

...
    print(ctx.get(dut.d).as_float())
...

Outputs:

0.6875
1.375
20.875
20.875
20.875

The workaround I found was to use an m.If and create two different arithmetic assignments.

@vk2seb
Copy link
Contributor Author

vk2seb commented May 5, 2025

The workaround I found was to use an m.If and create two different arithmetic assignments.

If one really wants to use a Mux that is lib.fixed aware, an easier workaround might be to build this on top of the primitives already supplied by lib.fixed, for example:

def FMux(test, a, b):
    if isinstance(a, fixed.Value) and isinstance(b, fixed.Value):
        f_bits = max(a.f_bits, b.f_bits)
        return fixed.Value(a.shape() if a.i_bits >= b.i_bits else b.shape(),
                     Mux(test, a.reshape(f_bits), b.reshape(f_bits)))
    elif isinstance(a, fixed.Value):
        return fixed.Value(a.shape(), Mux(test, a, b))
    elif isinstance(b, fixed.Value):
        return fixed.Value(b.shape(), Mux(test, a, b))
    else:
        raise TypeError("FMux should only be used on fixed.Value")

On your example:

self.a.eq(fixed.Const(-1.125) + FMux(1, fixed.Const(1.375), 0)),
self.b.eq(fixed.Const(-1.125) + fixed.Const(1.375)),
self.c.eq(FMux(1, fixed.Const(1.375), 0)),
self.d.eq(fixed.Const(-1.125) + FMux(1, fixed.Const(1.375), 0)),
self.e.eq(fixed.Const(-1.125) + FMux(1, fixed.Const(1.375), 0)),
self.f.eq(fixed.Const(-1.125) + FMux(1, fixed.Const(1.375), fixed.Const(0))),

# Prints
fixed.Const(0.25, SQ(8, 4))
fixed.Const(0.25, SQ(8, 4))
fixed.Const(1.375, SQ(8, 4))
fixed.Const(0.25, SQ(8, 4))
fixed.Const(0.25, SQ(8, 4))
fixed.Const(0.25, SQ(8, 4))

I would however not include such a workaround in this RFC. I think attacking this properly would imply modifying Mux to preserve shapes. Which should be a separate RFC, as it has wider consequences than just adding a module to the standard library as lib.fixed aims to do.

Whether lib.fixed should be blocked by a shape-preserving Mux implementation is not something I have a strong opinion on, at least in my DSP codebases I haven't found much use for Mux outside of .clamp() which is provided here and is already shape-preserving.

@goekce
Copy link

goekce commented May 5, 2025

Thanks for the FMux. I will try it.

IMHO fixed could attract other users, so I would not block it, if shape preserving Mux has not a high priority right now. Mux issue should then be noted though.

@goekce
Copy link

goekce commented May 21, 2025

  1. I tried FMux and it works 🙂. I find it a short way of expressing statements like z = a + (b if c else d) + e instead of writing if ... then z = a + b + e else z = a + d + e.

  2. I noticed:

    In [30]: fixed.Const(-2)
    Out[30]: fixed.Const(-2.0, SQ(3, 0))

    -2 requires only two bits in two's complement. Is it intended that it generates 3 bits?

Comment on lines +127 to +137
def reshape(self, f_bits):
# If we're increasing precision, extend with more fractional bits. If we're
# reducing precision, truncate bits.
shape = hdl.Shape(self.i_bits + f_bits, signed=self.signed)
if f_bits > self.f_bits:
result = Shape(shape, f_bits)(hdl.Cat(hdl.Const(0, f_bits - self.f_bits), self.as_value()))
elif f_bits < self.f_bits:
result = Shape(shape, f_bits)(self.as_value()[self.f_bits - f_bits:])
else:
result = Shape(shape, f_bits)(self.as_value())
return result
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I additionally noticed that reshape(shape) from the RFC is not implemented. Is this intended?

@vk2seb
Copy link
Contributor Author

vk2seb commented May 30, 2025

-2 requires only two bits in two's complement. Is it intended that it generates 3 bits?

Agree this is an edgecase in const size inference. Let me add a test case for that and fix. Thanks! 👍

I additionally noticed that reshape(shape) from the RFC is not implemented. Is this intended?

Yes this is intended, my plan was to change the RFC text to reflect this, as I couldn't find a good usecase for the second form of reshape(). I touched on it in the RFC PR here: amaranth-lang/rfcs#41 (comment)

Comment on lines +127 to +137
def reshape(self, f_bits):
# If we're increasing precision, extend with more fractional bits. If we're
# reducing precision, truncate bits.
shape = hdl.Shape(self.i_bits + f_bits, signed=self.signed)
if f_bits > self.f_bits:
result = Shape(shape, f_bits)(hdl.Cat(hdl.Const(0, f_bits - self.f_bits), self.as_value()))
elif f_bits < self.f_bits:
result = Shape(shape, f_bits)(self.as_value()[self.f_bits - f_bits:])
else:
result = Shape(shape, f_bits)(self.as_value())
return result
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reshape doesn't work when self.i_bits == 0 and f_bits == 0.
This may happen with unsigned, fraction only types.
As this would just always return a 0-value it may be of questionable use, but for the sake of consistency I think it should work.

Furthermore this breaks eq when assigning a value with no integer part to a signal with on fractional part. In that case I think it should either assign constant 0 or throw an exception

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Furthermore this breaks eq when assigning a value with no integer part to a signal with on fractional part. In that case I think it should either assign constant 0 or throw an exception

We already throw an exception in this case. Currently I get:

>>> Signal(fixed.UQ(2, 0)).eq(fixed.Const(0, fixed.UQ(0, 2)))
amaranth/lib/fixed.py:124: in eq
    other = other.reshape(self.f_bits)
            ^^^^^^^^^^^^^^^^^^^^^^^^^^
amaranth/lib/fixed.py:134: in reshape
    result = Shape(shape, f_bits)(self.as_value()[self.f_bits - f_bits:])
             ^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = UQ(0, 0), shape = unsigned(0), f_bits = 0

    def __init__(self, shape, f_bits=0):
        self._storage_shape = shape
        self.i_bits, self.f_bits = shape.width-f_bits, f_bits
        if self.i_bits < 0 or self.f_bits < 0:
            raise TypeError(f"fixed.Shape may not be created with negative bit widths (i_bits={self.i_bits}, f_bits={self.f_bits})")
        if shape.signed and self.i_bits == 0:
           raise TypeError(f"A signed fixed.Shape cannot be created with i_bits=0")
        if self.i_bits + self.f_bits == 0:
>           raise TypeError(f"fixed.Shape may not be created with zero width")
E           TypeError: fixed.Shape may not be created with zero width

Although I agree that it may be worth adding a check for this inside reshape, to give the user a more helpful error message a bit earlier rather than falling through to the Shape constructor.

I would prefer to raise a more useful error message in these cases, rather than permit an operation that has questionable / no real use. Unless there is some killer reason to permit it?

Copy link

@qbojj qbojj Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really see why are 0-width unsigned fixed point values prohibited, as the standard signals allow for such a case (unsigned(0) is OK, but `signed(0) is not).

I don't know how frequently it would show itself, but something like this triggers the problem:

v = Signal(fixed.UQ(1,0))
m.d.sync += v.eq(v >> 1)

It turns out there are some more problems with zero-wide assignments to signed fixed point values:

v = Signal(fixed.SQ(1,0))
m.d.sync += v.eq(v >> 1)

It seems like there is more fundamental problem with shifts by signal:

x = Signal(range(8))
v = Signal(fixed.UQ(10,0))
assert (v >> x).shape() == fixed.UQ(3,7)  # should be UQ(10,7)

The left shifts don't suffer the same problems.

This was why I even found the reshape problem (unsiged fixed got right-shifted by a signal and assigned)


I think I have fixed all of that in qbojj/PixelForge@23244b8

@vk2seb
Copy link
Contributor Author

vk2seb commented Dec 27, 2025

Thanks @qbojj for taking a look at this! I'm currently out, so my responses are a bit slow for a couple weeks, but just wanted to mention I have read through your comments and will integrate them into this PR shortly.

@codecov
Copy link

codecov bot commented Jan 18, 2026

Codecov Report

❌ Patch coverage is 79.82833% with 47 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.07%. Comparing base (54dd41d) to head (4901d89).
⚠️ Report is 41 commits behind head on main.

Files with missing lines Patch % Lines
amaranth/lib/fixed.py 79.82% 32 Missing and 15 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1578      +/-   ##
==========================================
- Coverage   91.32%   91.07%   -0.26%     
==========================================
  Files          44       45       +1     
  Lines       11389    11726     +337     
  Branches     2219     2281      +62     
==========================================
+ Hits        10401    10679     +278     
- Misses        827      869      +42     
- Partials      161      178      +17     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@qbojj
Copy link

qbojj commented Feb 18, 2026

I don't know how usefull it would be for others, but from time to time I was using rounding like:

    def reshape_round(self, f_bits):
        # Reshape with rounding when reducing precision.
        if f_bits >= self.f_bits:
            return self.reshape(f_bits)
        else:
            r = self.reshape(f_bits)
            do_inc = self.as_value()[self.f_bits - f_bits - 1]
            return r.shape()(r.as_value() + do_inc)

    def reshape_ceil(self, f_bits):
        # Reshape with ceiling when reducing precision.
        if f_bits >= self.f_bits:
            return self.reshape(f_bits)
        else:
            r = self.reshape(f_bits)
            do_inc = self.as_value()[self.f_bits - f_bits - 1 :] != 0
            return r.shape()(r.as_value() + do_inc)
            
    def truncate_round(self, f_bits=0): ...
    def truncate_ceil(self, f_bits=0): ...

    def floor(self) -> hdl.Value:
        return self.truncate(0).as_value()

    def ceil(self) -> hdl.Value:
        return self.truncate_ceil(0).as_value()

    def round(self) -> hdl.Value:
        return self.truncate_round(0).as_value()

PS. all my code samples are from qbojj/PixelForge/gpu/utils/fixed.py, including semi-capable formatter

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

4 participants